Skip to content

Add CLI OAuth device login#1590

Merged
ChiragAgg5k merged 30 commits into
mainfrom
codex/cli-oauth-device-login
Jun 19, 2026
Merged

Add CLI OAuth device login#1590
ChiragAgg5k merged 30 commits into
mainfrom
codex/cli-oauth-device-login

Conversation

@ChiragAgg5k

@ChiragAgg5k ChiragAgg5k commented Jun 16, 2026

Copy link
Copy Markdown
Member

Summary

This PR adds the new browser-based OAuth2 device login flow to the Appwrite CLI while preserving existing self-hosted email/password login and legacy cookie sessions. It also hardens session switching, logout, reset, token refresh, deployment log streaming, and local Cloud development behavior around the new auth model.

What Changed

Browser-based CLI login

  • Adds OAuth2 device authorization login for Appwrite Cloud endpoints.
  • Stores OAuth access token, refresh token, token expiry, and OAuth client ID in CLI preferences.
  • Refreshes expired access tokens through the OAuth refresh-token grant before authenticated API calls.
  • Verifies the newly-created OAuth session by calling /account before reporting login success.
  • Restores the previous session and removes partial login state if OAuth login verification fails.
  • Handles OAuth device polling errors more defensively, including missing/empty error response bodies.
  • Honors OAuth slow_down responses by increasing the polling interval.
  • Keeps login --new from showing the legacy-session warning for the command that is explicitly migrating to the new flow.

Backward compatibility and self-hosted login

  • Keeps the existing email/password login flow for self-hosted endpoints.
  • Preserves MFA support for self-hosted email/password login.
  • Fixes failed MFA cleanup so a bad OTP, cancelled prompt, invalid factor, or challenge failure does not leave a partial current session behind.
  • Allows stale/broken current sessions to recover during login instead of blocking the user from signing in again.
  • Adds a local development override so APPWRITE_CLI_DEV_CLOUD_LOGIN=1 treats localhost/loopback endpoints as Cloud for OAuth device login testing.

Session switching and auth precedence

  • Fixes login --switch so selecting an existing account actually switches instead of starting a new OAuth login.
  • Verifies the selected session before reporting switch success.
  • Restores the previous current session when a selected session is expired, revoked, or fails token refresh.
  • Updates project client auth precedence so OAuth bearer/cookie admin sessions are used before falling back to a saved API key.
  • Keeps regional project API endpoints intact while using the generic Cloud console endpoint only where the OAuth console flow requires it.

Logout, reset, and server-side cleanup

  • Revokes OAuth refresh tokens on logout/reset where possible.
  • Deletes legacy cookie sessions on the server instead of only removing local config.
  • During OAuth migration, revokes/removes legacy cookie sessions only after server cleanup succeeds, and keeps/warns for sessions that could not be revoked.
  • Fixes grouped-account logout where one saved session succeeds and a sibling fails: the CLI now restores a surviving failed session instead of leaving no current session.
  • Treats endpoint/key-only sessions as local-only entries so client --reset can fully clear local CLI configuration without requiring server revocation.

Deployment and realtime behavior

  • Allows realtime deployment log streaming to authenticate with OAuth bearer tokens.
  • Refreshes OAuth access tokens before opening deployment realtime connections, matching REST client behavior.
  • Fixes local Cloud deployment page links: when running against localhost, the CLI fetches the project region in memory and formats console links like project-fra-<projectId> without writing region data into appwrite.config.json.
  • Keeps production Cloud link behavior unchanged by deriving regions from regional Cloud endpoint hostnames.

Generated CLI output

  • Updates CLI templates and regenerates the CLI example output after template changes.
  • Updates CLI lockfile templates through the lockfile update workflow.
  • Uses the preview @appwrite.io/console package that includes OAuth2 device authorization, token refresh, and revoke support.

Testing

Commands run during this PR:

  • ./scripts/update-lockfiles.sh cli
  • php example.php cli
  • composer lint-twig
  • composer refactor:check
  • vendor/bin/phpunit --testsuite Unit
  • npm install in examples/cli
  • npm run build:types in examples/cli
  • npm run build:runtime in examples/cli
  • npm run build in examples/cli
  • npm run mac-arm64 in examples/cli
  • npx eslint lib/commands/generic.ts lib/commands/init.ts lib/sdks.ts lib/types.ts lib/commands/utils/deployment.ts in examples/cli

Manual/local verification performed:

  • Smoke-tested ./build/appwrite-cli-darwin-arm64 login --new --endpoint "http://localhost/v1" against a local Cloud server with APPWRITE_CLI_DEV_CLOUD_LOGIN=1.
  • Verified login --new no longer shows the legacy cookie-session warning for that command.
  • Verified OAuth-authenticated project calls against local Cloud after the matching Cloud auth bridge fix.
  • Verified project list-policies --limit 1 succeeds with bearer auth against local Cloud.
  • Verified local deployment page URL formatting produces http://localhost/console/project-fra-... for regional local Cloud projects.

Login flow examples

Browser-based OAuth2 device login (Appwrite Cloud)

When OAuth login is enabled, appwrite login against a Cloud endpoint starts the device-authorization flow: it prints a one-time code and verification URL, then polls until you approve in the browser.

$ appwrite login

To sign in, confirm the code below in your browser:

  Code: WDJB-MJHT
  URL:  https://cloud.appwrite.io/auth/device?user_code=WDJB-MJHT

⠹ Waiting for approval...
✓ Success: Successfully signed in as jane@example.com
♥ Hint: Next you can create or link to your project using 'appwrite init project'

The access and refresh tokens are stored in the CLI preferences and the access token is refreshed automatically before authenticated calls (honoring slow_down backoff while polling, per RFC 8628). The refresh token is revoked on logout.

Feature flag (staged rollout)

The device flow is gated behind an opt-in environment variable, off by default, so Cloud keeps using email/password until it is explicitly enabled:

# default: email/password login
$ appwrite login

# opt in to the new browser-based device login
$ APPWRITE_CLI_OAUTH_LOGIN=1 appwrite login

Self-hosted email/password (preserved)

Self-hosted endpoints — and Cloud while the flag is off — keep the existing email/password flow, including MFA:

$ appwrite login --endpoint https://appwrite.example.com/v1
? Enter your email jane@example.com
? Enter your password ********
✓ Success: Successfully signed in as jane@example.com
♥ Hint: Next you can create or link to your project using 'appwrite init project'

When MFA is required the CLI prompts for a factor and code next (or accepts --mfa / --code non-interactively).

Adding and switching accounts

$ appwrite login
✓ Success: Already logged in as jane@example.com
♥ Hint: Use 'appwrite login --new' to add another account

$ appwrite login --switch
? Select account: (Use arrow keys)jane@example.com  current  https://cloud.appwrite.io/v1
  ops@example.com            https://appwrite.example.com/v1
✓ Success: Switched to ops@example.com

Legacy cookie sessions

Existing cookie sessions keep working. When OAuth login is enabled, the CLI nudges you to migrate, and login --new removes legacy cookie sessions after the new session is verified:

$ appwrite login
ℹ Warning: You are using a legacy cookie session. Run 'appwrite login --new' to switch to the new browser-based login flow.

Sign out

$ appwrite logout
✓ Success: Logged out successfully

@ChiragAgg5k ChiragAgg5k marked this pull request as ready for review June 17, 2026 11:05
@greptile-apps

greptile-apps Bot commented Jun 17, 2026

Copy link
Copy Markdown
Contributor

Greptile Summary

This PR adds an OAuth2 device-authorization login flow for Appwrite Cloud while preserving the existing email/password path for self-hosted instances. Token storage, auto-refresh, and server-side revocation on logout/reset are introduced across new auth/ modules, and the existing generic.ts login command is refactored into those modules.

  • OAuth2 device flow: polling, slow-down backoff, token storage (accessToken / refreshToken / tokenExpiry / clientId), and auto-refresh via getValidAccessToken are implemented correctly per RFC 8628.
  • Session lifecycle: logoutSessions now attempts individual server revocation for every credential in an account group before removing local records; deleteServerSession correctly uses each target session's own selfSigned flag.
  • Backward compatibility: the email/password + MFA path for self-hosted instances is preserved; MFA questionsListFactors now correctly attaches the partial cookie session (requiresAuth: true) so listMfaFactors() can run.

Confidence Score: 3/5

The auth refactor introduces correct OAuth polling, MFA cleanup, and per-credential server revocation, but two important behaviors in the auth path still need attention before this is production-ready.

The single-session logout path prints 'Logged out successfully' unconditionally even when server revocation fails and the local session is restored. In sdkForProject, when an OAuth access token is present but its refresh token is revoked, getValidAccessToken throws before the API-key branch is ever reached, so users who mixed OAuth login with a configured key can lose access to project commands without a clear path to recovery.

templates/cli/lib/commands/generic.ts (single-session logout success message) and templates/cli/lib/sdks.ts (OAuth-throws-before-key-fallback in sdkForProject)

Important Files Changed

Filename Overview
templates/cli/lib/auth/login.ts New file: implements email/password, MFA, OAuth device, and switch-account login flows. MFA cleanup and switch-account restoration now properly handled.
templates/cli/lib/auth/oauth.ts New file: OAuth2 device-authorization polling. Correctly handles slow_down backoff, default interval, and empty/unrecognized error bodies. Safe to merge.
templates/cli/lib/auth/session.ts New file: session lifecycle helpers. logoutSessions correctly revokes each credential individually. deleteServerSession uses target session's selfSigned setting.
templates/cli/lib/commands/generic.ts Major refactor: login logic extracted to auth/login.ts, logout/reset use logoutSessions. Single-session logout still prints success unconditionally even when server revocation fails and local session is restored.
templates/cli/lib/sdks.ts Adds getValidAccessToken with auto-refresh. If access token is present but refresh fails, the key branch is never reached — users with a key configured can lose access when their OAuth token expires.
templates/cli/lib/commands/utils/deployment.ts Realtime WebSocket now calls getValidAccessToken before opening the connection, ensuring a fresh bearer token is sent.
templates/cli/lib/commands/push.ts Adds getConsoleUrlProjectRegion to fetch project region in-memory for localhost/self-hosted. Promise-caching per projectId prevents duplicate API calls.
templates/cli/lib/questions.ts questionsListFactors.choices now uses sdkForConsole() (requiresAuth: true). During MFA login, the partial cookie session is attached correctly.

Reviews (21): Last reviewed commit: "(chore): use published @appwrite.io/cons..." | Re-trigger Greptile

Comment thread templates/cli/lib/commands/generic.ts Outdated
Comment thread templates/cli/lib/commands/generic.ts Outdated
Comment thread templates/cli/lib/commands/utils/deployment.ts
Comment thread templates/cli/lib/commands/generic.ts Outdated
Comment thread templates/cli/lib/sdks.ts Outdated
Comment thread templates/cli/lib/commands/generic.ts Outdated
Comment thread templates/cli/lib/commands/generic.ts Outdated
Comment thread templates/cli/lib/commands/generic.ts Outdated
Comment thread templates/cli/lib/questions.ts
Comment thread templates/cli/lib/commands/generic.ts Outdated
Comment thread templates/cli/lib/commands/generic.ts Outdated
Comment thread templates/cli/lib/commands/generic.ts Outdated
Comment thread templates/cli/lib/commands/generic.ts Outdated
Comment thread templates/cli/lib/sdks.ts Outdated
Comment thread templates/cli/lib/commands/generic.ts
Comment thread templates/cli/lib/commands/generic.ts Outdated
Extract the OAuth device-login logic that landed in commands/generic.ts
into a cohesive lib/auth/ layer, preserving all behavior:

- lib/auth/oauth.ts: OAuth2 client factory, device-token polling, token
  refresh, revoke, and id_token decoding (getValidAccessToken moved here
  from sdks.ts; the 3x-duplicated Oauth2 client construction is unified
  behind createOauth2).
- lib/auth/session.ts: typed session accessor, classification + current-
  session helpers, deleteServerSession, and a single logoutSessions() that
  replaces the three near-identical cleanup loops.
- lib/auth/login.ts: login orchestration and flows (loginCommand,
  loginWithEmailPassword, loginWithOAuthDevice, switchToAccount,
  completeMfaLogin, getCurrentAccount).
- utils.ts: endpoint classifiers placed next to isCloudHostname.
- generic.ts slimmed from ~1060 to ~330 lines (command definitions only).

Repointed sdks.ts/deployment.ts imports and registered the new auth
files in CLI.php getFiles(). Device-login output uses process.stdout.write
(matching the existing spinner idiom); dropped thin wrapper helpers.
Comment thread templates/cli/lib/auth/session.ts
The questionsListFactors choices loader built its console SDK with
requiresAuth: false. Since the OAuth login change made sdkForConsole
attach the session cookie only when requiresAuth is true, the MFA factor
list was fetched as a guest and failed with 401 — blocking self-hosted
email/password logins that reach MFA without --mfa from picking a factor.

Use an authenticated client so the partial MFA session cookie is sent.
Add a logic-layer auth section to the CLI e2e script that asserts the
pure/near-pure functions introduced by the OAuth device-login work:
endpoint classification (cloud/regional/localhost/dev-override), id_token
decoding, authorization-pending detection, device-token polling
(success/retry/error/timeout via a fake oauth2), cached access-token
reuse, session classifiers, planSessionLogout grouping, and
restoreCurrentSessionFallback. Driven by AUTH_LOGIC_RESPONSES in Base.php
and the three CLIBun test classes; no new runner or mock-server changes.
Comment thread templates/cli/lib/auth/login.ts Outdated
Add lib/flags.ts, a central registry of CLI feature flags read from env
vars via isFlagEnabled("<name>") so future flags are a one-line addition.
Route the OAuth device-login gate (APPWRITE_CLI_OAUTH_LOGIN) and the
localhost-as-Cloud dev override (APPWRITE_CLI_DEV_CLOUD_LOGIN) through it,
replacing the ad-hoc per-flag helpers in utils.ts.

OAuth device login stays off by default: Cloud endpoints use email/password
login (and the legacy-cookie migration nudges stay silent) until the flag is
enabled. Covered by the e2e auth-logic assertions.
pollForDeviceToken now honors slow_down by increasing the polling interval
by 5s per RFC 8628 (it previously retried at the original interval), and
treats an empty/unrecognized error body during polling as a transient
pending response instead of aborting the device flow with a blank
AppwriteException. Genuine terminal errors still propagate. Adds e2e
coverage for the slow_down backoff and empty-body retry paths.
The CLI generator renders lib/constants.ts from constants.ts.twig (the only
entry registered in CLI.php getFiles); the plain constants.ts was a stale
leftover from an earlier refactor, missing CONFIG_RESOURCE_KEYS,
HOMEBREW_FORMULA, UPDATE_CHECK_INTERVAL_MS, and TOP_LEVEL_RESOURCE_ARRAY_KEYS.
It is never read during generation, so the generated SDK was unaffected, but
building the template dir directly imported the stale file and failed.
Removing it leaves a single source of truth. Generated output is unchanged.
Comment thread templates/cli/lib/auth/oauth.ts Outdated
…elf-signed legacy revoke

- pollForDeviceToken now defaults to a 5s interval when the device
  authorization response omits one (RFC 8628 §3.5); previously an undefined
  interval produced NaN and busy-polled the token endpoint until expiry.
- getCurrentAccount/switchToAccount no longer persist the normalized console
  endpoint back into the session. sdkForConsole already normalizes when
  building the console client, so persisting it overwrote a regional Cloud
  endpoint and could route later project calls to the generic Cloud host.
- deleteServerSession builds the legacy client with the target session's own
  selfSigned setting (added to SessionData) instead of the current session's,
  so revoking a self-signed legacy session during OAuth migration no longer
  fails TLS verification and strand the session.

Adds an e2e assertion for the default polling interval.
Comment thread templates/cli/lib/auth/session.ts
The previous default-interval guard treated interval <= 0 as omitted, so a
deviceAuth with interval 0 (used to poll without delay) was forced to the 5s
default and consumed the whole expiry window before the next attempt, breaking
the retry/slow_down/empty-body poll paths in CI. Only non-finite (omitted)
intervals now fall back to 5s; an explicit 0 is honored. Strengthens the
default-interval test to assert the fallback delay actually applies.
templates/cli/lib/constants.ts is the local type-check stand-in for
constants.ts.twig (tsc resolves ./constants.js to the plain .ts; the .twig is
invisible to it), letting templates/cli type-check before generation. It had
drifted from the twig, missing UPDATE_CHECK_INTERVAL_MS, HOMEBREW_TAP/FORMULA,
CONFIG_RESOURCE_KEYS, and TOP_LEVEL_RESOURCE_ARRAY_KEYS. Restored and synced so
its exported symbols match the twig. Not registered in getFiles, so generated
output is unchanged (it renders from the twig).
Replace the bespoke createOauth2 helper with the shared services factory.
The OAuth token/revoke/device-authorization clients are now built with
sdkForConsole({ requiresAuth: false, endpointOverride }) wrapped by
getOauth2Service, matching how every other service client is constructed.
requiresAuth:false keeps them unauthenticated (these calls establish/refresh
a session) and avoids recursing into getValidAccessToken.
getValidAccessToken (the bearer-refresh primitive that sdkForConsole/
sdkForProject depend on) moves from auth/oauth.ts into sdks.ts where it
belongs, and the shared OAUTH2_CLIENT_ID/OAUTH2_SCOPES move to constants.ts.
sdks.ts no longer imports auth/oauth.ts, so auth/oauth.ts is free to build its
OAuth2 clients via services.ts getOauth2Service without a circular import.

Net dependency flow is now acyclic: constants/config (leaves) -> sdks ->
services -> oauth -> session/login. getValidAccessToken constructs its refresh
client directly (it sits below the services factory); revoke and the device
flow use getOauth2Service.
…w build

Drops the pkg.vc preview pin (54cebb6) for the published ^15.0.0 release now
that the API surface (incl. the Usage service) is aligned in appwrite/specs.

- package.json/.twig -> ^15.0.0; lockfile twigs regenerated via update-lockfiles.sh
- oauth.ts: drop projectId from oauth2.revoke() (15.0.0 sets it on the client)
- bunfig.toml: exempt @appwrite.io/console from the 7d minimumReleaseAge guard
  (first-party SDK released in lockstep with the CLI; freshly-published
  releases must install immediately, e.g. for the CLIBun e2e bun install)

Verified against examples/cli: tsc + build:runtime clean, bun install resolves
15.0.0 under the bunfig policy.
@ChiragAgg5k ChiragAgg5k merged commit 33a0379 into main Jun 19, 2026
91 of 93 checks passed
@ChiragAgg5k ChiragAgg5k deleted the codex/cli-oauth-device-login branch June 19, 2026 04:27
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant